<?php

/**
 * Behavior for binding and translation management
 *
 * PHP versions 5
 * CAKEPHP versions 2.x
 * 
 * Green CMS - Content Management System and Framework Powerfull by Cakephp
 * Copyright 2012, GREEN GLOBAL CO., LTD (toancauxanh.vn)
 * 
 * CakePHP(tm) :  Rapid Development Framework (http://www.cakephp.org)
 * Copyright 2005-2011, Cake Software Foundation, Inc. (http://www.cakefoundation.org)
 *
 * Licensed under The MIT License
 * Redistributions of files must retain the above copyright notice.
 *
 * @author        Technology Lab No.I <tech1@toancauxanh.vn>
 * @link          
 * @package       Green.Model.Behavior
 * @since         Green v 1.0
 * @license       MIT License (http://www.opensource.org/licenses/mit-license.php)
 */
App::uses('I18n', 'I18n');
App::uses('I18nModel', 'Model');
App::uses('ContainableBehavior', 'Model/Behavior');

class TranslatorBehavior extends ContainableBehavior {

    /**
     * Runtime configuration for this behavior
     *
     * @var array
     */
    public $runtime = array();

    /**
     * Initiate behavior for the model using specified settings.
     *
     * Available settings:
     *
     * - recursive: (boolean, optional) set to true to allow containable to automatically
     *   determine the recursiveness level needed to fetch specified models,
     *   and set the model recursiveness to this level. setting it to false
     *   disables this feature. DEFAULTS TO: true
     * - notices: (boolean, optional) issues E_NOTICES for bindings referenced in a
     *   containable call that are not valid. DEFAULTS TO: true
     * - autoFields: (boolean, optional) auto-add needed fields to fetch requested
     *   bindings. DEFAULTS TO: true
     * 
     * Available runtime:
     * 
     * - alias: string alias of model was translated by Translate behavior. DEFAULTS TO: name class of $Model
     * - locale: (boolean, string) set true to get the current locale , 
     *   string of you want set other locale , false to disable translate content. DEFAULTS TO: false
     *   containable call that are not valid. DEFAULTS TO: true
     * - auto: (boolean, optional) auto use for filling translated fields bindings. DEFAULTS TO: true
     * - reset (boolean, optional) don't reset contain binding and runtime configuration if set false. DEFAULTS TO: true
     *   If you use option reset=false then the system may by not operate correctly
     *
     * @param Model $Model Model using the behavior
     * @param array $config Settings to override for model.
     * @return void
     * @see ContainableBehavior::setup
     */
    public function setup(Model $Model, $config = array()) {
        $settings = array('priority' => 1);
        foreach (array('recursive', 'notices', 'autoFields', 'priority') as $configKey) {
            if (isset($config[$configKey])) {
                $settings[$configKey] = $config[$configKey];
            }
            unset($config[$configKey]);
        }
        parent::setup($Model, $settings);
        $this->localize($Model, $config);
        $this->bindTranslation($Model, $Model->translateFields);
    }

    /**
     * beforeFind Callback
     * 
     * Runs before a find() operation. Used to auto filling translated fields to containments if locale set
     *
     * `Model->find('all', array('locale'=>true,'contain' => array('Model1', 'Model2')));`
     * `Model->find('all', array('locale'=>'vie','contain' => array('Model1', 'Model2')));`
     * `Model->find('all', array('locale'=>false,'contain' => array('Model1', 'Model2')));`
     *
     * @param Model $Model	Model using the behavior
     * @param array $query Query parameters as set by cake
     * @return array
     * @see ContainableBehavior::beforeFind
     */
    public function beforeFind(Model $Model, $query) {
        if (isset($query['locale'])) {
            $this->localize($Model, $query['locale']);
        }

        $options = $this->runtime[$Model->alias];
        unset($this->runtime[$Model->alias]['contain']);

        $query += array('fields' => array(), 'contain' => null, 'reset' => $options['reset'], 'recursive' => null);
        if (isset($options['contain'])) {
            $query['contain'] = array_merge((array) $options['contain'], (array) $query['contain']);
        }
        $maps = array();
        if ($Model->findQueryType !== 'count' && $options['locale'] && Router::isMultilingual()) {
            $containments = array_merge((array) $query['contain'], array('fields' => $query['fields']));
            $containments = $this->translations($Model, $maps, $containments, $options['locale'], $options['auto']);
            $query['fields'] = $containments['fields'];
            unset($containments['fields']);
            if (!empty($containments)) {
                $query['contain'] = $containments;
            }
        }

        $query = parent::beforeFind($Model, $query);
        if (!empty($Model->__backOriginalTranslator)) {
            $this->runtime[$Model->alias] = $Model->__backOriginalTranslator;
            $Model->__backOriginalTranslator = array();
        }
        $this->runtime[$Model->alias]['maps'] = $maps;
        return $query;
    }

    /**
     * afterFind Callback
     * 
     * Runs after a find() operation. Used to auto filling value of translated fields to results
     *
     * @param Model $Model Model on which we are resetting
     * @param array $results Results of the find operation
     * @param boolean $primary true if this is the primary model that issued the find operation, false otherwise
     * @return $results
     */
    public function afterFind(Model $Model, $results, $primary) {
        if (!empty($this->runtime[$Model->alias]['maps'])) {
            $results = $this->_translationMap($Model->alias, $results, $this->runtime[$Model->alias]['maps']);
        }
        unset($this->runtime[$Model->alias]['maps']);
        return $results;
    }

    /**
     * afterDelete Callback
     * 
     * Runs after a delete() operation. Used to delete value of translated fields
     * 
     * @param Model $Model Model on which we are resetting
     * @return void
     */
    public function afterDelete(Model $Model) {
        $RuntimeModel = $this->translateModel($Model);
        $conditions = array('model' => $this->runtime[$Model->alias]['alias'], 'foreign_key' => $Model->id);
        $RuntimeModel->deleteAll($conditions, false, false);
    }

    /**
     * Get instance of model for translations.
     *
     * If the model has a translateModel property set, this will be used as the class
     * name to find/use.  If no translateModel property is found 'I18nModel' will be used.
     *
     * @param Model $Model Model to get a translatemodel for.
     * @return Model
     */
    public function translateModel(Model $Model) {
        if (!isset($this->settings[$Model->alias]['model'])) {
            if (!isset($Model->translateModel) || empty($Model->translateModel)) {
                $className = 'I18nModel';
            } else {
                $className = $Model->translateModel;
            }

            $this->settings[$Model->alias]['model'] = ClassRegistry::init($className, 'Model');
        }
        if (!empty($Model->translateTable) && $Model->translateTable !== $this->settings[$Model->alias]['model']->useTable) {
            $this->settings[$Model->alias]['model']->setSource($Model->translateTable);
        } elseif (empty($Model->translateTable) && empty($Model->translateModel)) {
            $this->settings[$Model->alias]['model']->setSource('i18n');
        }
        return $this->settings[$Model->alias]['model'];
    }

    /**
     * Bind translation for fields, optionally with hasMany association for
     * fake field
     *
     * @param Model $Model instance of model
     * @param mixel $fields string with field or array(field1, field2=>AssocName, field3)
     * @param $throwErrors Wether unexisting bindings show throw errors
     * 
     * @return boolean
     */
    public function bindTranslation(Model $Model, $fields, $throwErrors = null) {
        $RuntimeModel = $this->translateModel($Model);
        $associations = array();
        $default = array('className' => $RuntimeModel->alias, 'foreignKey' => 'foreign_key');

        if ($throwErrors === null) {
            $throwErrors = (empty($this->settings[$Model->alias]) ? true : $this->settings[$Model->alias]['notices']);
        }

        $fields = $this->translateClassify($Model, $fields);
        foreach ($fields as $field => $association) {
            $this->settings[$Model->alias]['fields'][$field] = $association;
            foreach (array('hasOne', 'hasMany', 'belongsTo', 'hasAndBelongsToMany') as $type) {
                if (isset($Model->{$type}[$association]) || isset($Model->__backAssociation[$type][$association])) {
                    if ($throwErrors) {
                        trigger_error(__d('debug', 'Association %s is already bound to model %s', $association, $Model->alias), E_USER_ERROR);
                    }
                    continue;
                }
            }
            $associations[$association] = array_merge($default, array('conditions' => array(
                    'model' => $Model->alias,
                    $RuntimeModel->displayField => $field)));
        }

        if (!empty($associations)) {
            $Model->bindModel(array('hasMany' => $associations), false);
        }
        return true;
    }

    /**
     * Unbind translation for fields, optionally unbinds hasMany association for
     * fake field
     *
     * @param Model $Model instance of model
     * @param mixed $fields string with field, or array(field1, field2=>AssocName, field3), or null for
     *    unbind all original translations
     * @return boolean
     */
    public function unbindTranslation(Model $Model, $fields = null) {
        if (empty($fields) && empty($this->settings[$Model->alias]['fields'])) {
            return false;
        }
        if (empty($fields)) {
            $fields = $this->settings[$Model->alias]['fields'];
        }

        $RuntimeModel = $this->translateModel($Model);
        $associations = array();

        $fields = $this->translateClassify($Model, $fields);
        foreach ($fields as $field => $association) {
            unset($this->settings[$Model->alias]['fields'][$field]);
            if (!is_null($association) && (isset($Model->hasMany[$association]) || isset($Model->__backAssociation['hasMany'][$association]))) {
                $associations[] = $association;
            }
        }

        if (!empty($associations)) {
            $Model->unbindModel(array('hasMany' => $associations), false);
        }
        return true;
    }

    /**
     * Returns an array store field and class name of translate fields
     * 
     * @param Model $Model Model on trigger
     * @param mixed $fields, an string field or array field to convert
     * @return array classify of fields
     */
    public function translateClassify(Model $Model, $fields) {
        if (is_string($fields)) {
            $fields = array($fields);
        }
        foreach ($fields as $field => $classify) {
            if (is_numeric($field)) {
                unset($fields[$field]);
                $fields[$classify] = Inflector::classify($classify . "Translation");
            }
        }
        return $fields;
    }

    /**
     * Returns translated fields
     * 
     * 
     * @param Model $Model Model on trigger
     * @return array list translated field
     */
    public function translateFields(Model $Model) {
        $fields = array();
        if (!empty($this->settings[$Model->alias]['fields'])) {
            $fields = $this->settings[$Model->alias]['fields'];
        }
        return $fields;
    }

    /**
     * Create translate data , when trigger all callback as Model::save ,
     * the property 'translate' will add to parameter $options
     * 
     * {{{
     * Model->locale = 'vie';
     * Model->translate(array('name'=>'translate value of name'));
     * }}}
     * 
     * @param Model $Model Model on trigger
     * @param array $data Data to translate.
     * @param mixed $validate Either a boolean, or an array.
     *   If a boolean, indicates whether or not to validate before saving.
     *   If an array, allows control of validate and callbacks
     * @return boolean true on success , false on failure
     * @see Model::save
     */
    public function translate(Model $Model, $data, $validate = true) {
        $fields = $this->translateFields($Model);
        if ($fields && $Model->exists()) {

            if (!is_array($validate)) {
                $validate = array('validate' => $validate);
            }
            $validate += array('validate' => true, 'callbacks' => true);

            $options = array_merge($validate, array(
                'fieldList' => array_keys($fields),
                'translate' => true));

            $_whitelist = $Model->whitelist;

            $Model->set($data);
            if (empty($Model->data[$Model->alias])) {
                return false;
            }

            $Model->whitelist = $options['fieldList'];
            if ($options['validate'] && !$Model->validates($options)) {
                $Model->whitelist = $_whitelist;
                return false;
            }

            if (($options['callbacks'] === true || $options['callbacks'] === 'before')) {
                $event = new CakeEvent('Model.beforeSave', $Model, array($options));
                list($event->break, $event->breakOn) = array(true, array(false, null));
                $Model->getEventManager()->dispatch($event);
                if (!$event->result) {
                    $Model->whitelist = $_whitelist;
                    return false;
                }
            }

            $data = array_intersect_key($Model->data[$Model->alias], $fields);
            $conditions = array('locale' => $this->_getLocale($Model), 'model' => $Model->alias, 'foreign_key' => $Model->id);
            $RuntimeModel = $this->translateModel($Model);

            foreach ($data as $field => $value) {
                $conditions['field'] = $field;
                $RuntimeModel->create(array_merge($conditions, array('content' => $value)));
                $translation = $RuntimeModel->find('first', array('conditions' => $conditions, 'fields' => array($RuntimeModel->alias . '.id')));
                if ($translation) {
                    $RuntimeModel->id = $translation[$RuntimeModel->alias]['id'];
                }
                $RuntimeModel->save(array($RuntimeModel->alias => $conditions));
            }

            if ($options['callbacks'] === true || $options['callbacks'] === 'after') {
                $event = new CakeEvent('Model.afterSave', $Model, array(false, $options));
                $Model->getEventManager()->dispatch($event);
            }

            $Model->whitelist = $_whitelist;
            return true;
        }
        return false;
    }

    /**
     * Set runtime config
     * 
     * {{{
     *    $Model->localize(true)->find('all');
     *    // or
     *    $Model->localize(true);
     *    $Model->find('all');
     * }}}
     * 
     * @param Model $Model Model on trigger
     * @param mixed $config Either a boolean, or an array.
     *   If a boolean, to set as locale
     *   If an array, config settings
     * @return $Model object
     * @see self::contain
     */
    public function localize(Model $Model, $config = array()) {
        if (!is_array($config)) {
            $config = array('locale' => $config);
        }
        if (empty($this->runtime[$Model->alias])) {
            $config = array_merge(array(
                'alias' => get_class($Model),
                'locale' => false,
                'contain' => null,
                'auto' => true,
                'reset' => true), $config);
            $this->runtime[$Model->alias] = $config;
        } else {
            $config = array_merge($this->runtime[$Model->alias], $config);
        }
        if ($config['locale'] && !is_string($config['locale'])) {
            $config['locale'] = $this->_getLocale($Model);
        }
        if (!empty($config['reset'])) {
            $Model->__backOriginalTranslator = $this->runtime[$Model->alias];
        }
        $this->runtime[$Model->alias] = $config;
        return $Model;
    }

    /**
     * Overrides ContainableBehavior::contain.
     * 
     * If you use option reset=false then the system may by not operate correctly
     *
     * @param Model $Model Model on which binding restriction is being applied
     * @return $Model object
     * @link http://book.cakephp.org/2.0/en/core-libraries/behaviors/containable.html#using-containable
     */
    function contain(Model $Model) {
        $reset = true;
        $contain = call_user_func_array('am', array_slice(func_get_args(), 1));
        if ((isset($contain[0]) && is_bool($contain[0])) || is_bool(end($contain))) {
            $reset = is_bool(end($contain)) ? array_pop($contain) : array_shift($contain);
        }
        return $this->localize($Model, array('contain' => $contain, 'reset' => $reset));
    }

    /**
     * Generate all contain relations use for filling translated fields bindings and mappings it.
     *
     * @param Model $Model Model on which binding restriction is being applied
     * @return array contain to bindings and fields to get
     * @see ContainableBehavior::contain
     */
    public function translations(Model $Model, &$maps, $containments, $locale, $auto) {
        $options = array('className', 'joinTable', 'with', 'foreignKey', 'associationForeignKey',
            'conditions', 'fields', 'order', 'limit', 'offset', 'unique', 'finderQuery', 'deleteQuery', 'insertQuery');
        $fields = $foreignKeys = array();
        if (!empty($containments['fields'])) {
            $fields = $this->_fieldSets($Model, $containments['fields']);
        }
        unset($containments['fields']);

        foreach ($containments as $name => $children) {
            $key = $name;
            if (is_numeric($name)) {
                $name = $children;
                $children = array();
            }
            if (is_string($name) && (!isset($Model->{$name}) || !is_object($Model->{$name})) &&
                    !($optionKey = in_array($name, $options, true)) && is_string($name) && preg_match('/^[a-z]/', $name) && !preg_match('/[ <>=!]/', $name)) {
                $fields[] = $name;
                unset($containments[$key]);
                continue;
            }
            if (empty($optionKey) && !empty($Model->{$name})) {
                $maps[$name] = array('multiple' => true);
                foreach (array('belongsTo', 'hasOne') as $type) {
                    if (isset($Model->{$type}[$name])) {
                        $foreignKeys[] = $Model->{$type}[$name]['foreignKey'];
                        $maps[$name]['multiple'] = false;
                    }
                }
                $containments[$name] = $this->translations($Model->{$name}, $maps[$name], $children, $locale, $auto);
                if ($key !== $name) {
                    unset($containments[$key]);
                }
            }
        }
        if (empty($fields)) {
            $fields = $this->_fieldSets($Model);
        } else {
            $fields = array_merge($fields, $foreignKeys);
        }
        $fields = array_unique($fields);
        $hasFields = $this->translateFields($Model);
        if ($hasFields) {
            $translateFields = array();
            foreach ($fields as $field) {
                if (strpos($field, '.') !== false) {
                    $field = explode(".", $field);
                    $field = array_pop($field);
                }
                if (isset($hasFields[$field])) {
                    $translateFields[$field] = $hasFields[$field];
                }
            }
            foreach ($translateFields as $field => $translateField) {
                if (isset($containments[$translateField])) {
                    continue;
                }
                $containments[$translateField]['conditions'] = array('locale' => $locale, 'model' => $this->runtime[$Model->alias]['alias'], 'field' => $field);
                if ($auto) {
                    $maps['localize'][$field] = $translateField;
                }
            }
        }
        return array_merge($containments, array('fields' => $fields));
    }

    /**
     * Clean up any initialization this behavior has done on a model.  Called when a behavior is dynamically
     * detached from a model using Model::detach().
     *
     * @param Model $Model Model using this behavior
     * @return void
     * @see BehaviorCollection::detach()
     */
    public function cleanup(Model $Model) {
        if (isset($this->runtime[$Model->alias])) {
            unset($this->runtime[$Model->alias]);
        }
        parent::cleanup($Model);
    }

    /**
     * Generate all fields in schema or convert string fields to an array.
     *
     * @param Model $Model Model on which binding restriction is being applied
     * @param mixel $fields field to convert , null to get from schema of $Model
     * @return converted $fields
     */
    protected function _fieldSets(Model $Model, $fields = null) {
        if (empty($fields)) {
            $fields = array_keys($Model->schema());
        } elseif (!is_array($fields)) {
            $fields = String::tokenize($fields);
        }
        return $fields;
    }

    /**
     * Used to auto filling value of translated fields to results by mappings has generate.
     *
     * @param string $className Model alias in $results
     * @param array $results data to filled
     * @param array $maps mappings has generate
     * @return filled $results
     * @see self::translation
     */
    protected function _translationMap($className, $results, $maps) {
        $multiple = isset($maps['multiple']) ? $maps['multiple'] : true;
        $localize = isset($maps['localize']) ? $maps['localize'] : array();
        unset($maps['localize'], $maps['multiple']);
        if (empty($results)) {
            return array();
        }
        if ($multiple === false) {
            $results = array($results);
        }
        foreach ($results as $key => $result) {
            foreach ($localize as $field => $alias) {
                if (!empty($result[$alias][0])) {
                    if (isset($result[$className])) {
                        $results[$key][$className][$field] = $result[$alias][0]['content'];
                    } else {
                        $results[$key][$field] = $result[$alias][0]['content'];
                    }
                }
                unset($results[$key][$alias]);
            }
            foreach ($maps as $alias => $map) {
                $results[$key][$alias] = $this->_translationMap($alias, $result[$alias], $map);
            }
        }
        if ($multiple === false && !empty($results[0])) {
            $results = $results[0];
        }
        return $results;
    }

    /**
     * Get selected locale for model
     *
     * @param Model $Model Model the locale needs to be set/get on.
     * @return mixed string or false
     */
    protected function _getLocale(Model $Model) {
        if (!isset($Model->locale) || is_null($Model->locale)) {
            $I18n = I18n::getInstance();
            $I18n->l10n->get(Configure::read('Config.language'));
            $Model->locale = $I18n->l10n->locale;
        }

        return $Model->locale;
    }

}

?>